Skip to main content

06 - 缓存:省下来的钱和省不掉的风险

前置01 - 网关是什么。如果读过 03 - 路由与容错 会更好,本篇末尾要把缓存和路由策略连起来。

本篇回答:同一个问题问两遍,第二遍能不能不花钱?"差不多的问题"算不算同一个问题?

会用到的词

  • embedding(向量化):把一段文字变成一个几百到几千维的浮点数组,语义相近的文字对应的向量在空间里也相近
  • 余弦相似度 / 欧氏距离:衡量两个向量有多近的两种算法。余弦是越大越像,欧氏是越小越像 —— 这个方向差异下面会变成一个真实的配置陷阱
  • TTFT:Time To First Token,首字延迟

一、四层缓存的分工

"AI 缓存"这个词被用得很乱,实际上有四种完全不同的东西,发生在四个不同的位置:

请求路径客户端网关Provider API推理引擎vLLM 等① 精确匹配缓存存在 Redis 里字符串完全相同才命中② 语义缓存存在��向量库里意思差不多就算命中③ Prompt Cachingprovider 自己提供前缀部分算折扣价④ Prefix Caching显存里的 KV Cache相同前缀不重算这次调用完全免费免费,但多花一次 embedding前缀部分按折扣价计费省的是算力,不是账单命中时到底省了什么
四层缓存不是四种实现同一件事的方案,而是发生在四个不同位置的四件事。它们能叠加,但对账单的影响完全不同 —— 只有前三层动到钱,第四层动的是算力。
判定标准省了什么谁来做
网关精确匹配字符串完全相同整次调用(钱和时间都省光)
网关语义缓存向量相似度超过阈值整次调用
Provider prompt caching请求前缀相同前缀部分的输入 token 费用provider
推理引擎 prefix caching请求前缀相同前缀部分的prefill 计算推理引擎

本篇只讲 ① 和 ②,因为只有这两层是网关的职责。 ③ 是 provider 的产品能力(你只能配合,不能实现),④ 是推理引擎内部的事 —— 那部分在 vLLM 推理专题 · Prefix Caching 里有完整拆解。

但四层是互相影响的,最后一节会讲这个。

二、Higress ai-cache 的两级结构

plugins/wasm-go/extensions/ai-cache/ 是本专题见过的组织最清楚的一个插件:

ai-cache/
├── main.go 7 KB ← 请求/响应钩子,缓存 key 怎么算
├── core.go 12 KB ← 查缓存、查向量库、判阈值
├── config/config.go 8 KB
├── cache/ ← 缓存后端(redis)
├── vector/ ← 向量库:dashvector chroma elasticsearch weaviate pinecone qdrant milvus
└── embedding/ ← 向量化服务:dashscope openai azure cohere ollama huggingface textin xfyun

七种向量库 + 八种 embedding 服务,这个数量本身就说明了一件事:语义缓存没有标准答案,每家的选型都不一样,所以插件必须把这两层做成可替换的。

2.1 先查精确匹配,再查语义

官方文档写得很直接:

This plugin supports both vector database-based semantic caching and string matching-based caching methods. If both vector database and cache database are configured, the cache database is used first, and the vector database capability is used when cache misses occur.

代码里就是这个顺序:

// core.go
func CheckCacheForKey(key string, ctx wrapper.HttpContext, c config.PluginConfig, log logs.Log, stream bool, useSimilaritySearch bool) error {
// ...
return performSimilaritySearch(key, ctx, c, log, key, stream)
// ...
handleCacheResponse(key, response, ctx, log, stream, c, useSimilaritySearch)
}

func handleCacheResponse(...) {
// ...
if useSimilaritySearch && c.EnableSemanticCache {
if err := performSimilaritySearch(key, ctx, c, log, key, stream); err != nil {
// ...
}
}
}

为什么必须是这个顺序? 因为语义缓存要先调一次 embedding 服务(一次网络请求 + 一次模型推理),再查一次向量库(第二次网络请求)。而精确匹配就是一次 Redis GET。

语义缓存本身是有成本的 —— 这是最容易被忽略的一点。如果你的流量里重复问题大多是一字不差的(比如前端固定按钮触发的请求),只开精确匹配就够了,开语义缓存反而每次多两跳。

2.2 缓存键的计算方式

多轮对话带来一个问题:这次请求的 key,是只用最后一个问题,还是要把整段历史都算进去?

// main.go
if c.CacheKeyStrategy == config.CACHE_KEY_STRATEGY_LAST_QUESTION {
// 只用最后一个问题
} else if c.CacheKeyStrategy == config.CACHE_KEY_STRATEGY_ALL_QUESTIONS {
// 把所有问题拼起来
} else if c.CacheKeyStrategy == config.CACHE_KEY_STRATEGY_DISABLED {
// 关闭
}

默认是 lastQuestion

这个默认值有一个非常危险的坑。 假设两个用户的对话是:

用户 A用户 B
第 1 轮"帮我分析这份财报""帮我写一首诗"
第 2 轮"再详细一点""再详细一点"

lastQuestion 策略下,两人第二轮的缓存 key 完全相同 —— 用户 B 会拿到用户 A 的财报分析。

allQuestions 能解决这个问题,代价是命中率大幅下降(对话越长越难命中)。

所以真正的判断是:你的场景是"一问一答的知识问答"(用 lastQuestion),还是"多轮上下文相关的对话"(必须用 allQuestions,或者干脆别开缓存)。默认值只对前者安全。

2.3 跳过缓存的逃生舱

const SKIP_CACHE_HEADER = "x-higress-skip-ai-cache"

官方说明:带上这个请求头,本次请求不读缓存、响应也不写缓存。

任何缓存系统都必须有这个开关。 用户点"重新生成"的时候,你绝对不能把上次那个他不满意的答案再给他一遍。

三、相似度阈值与方向配置

语义缓存的核心判断只有一行 —— 最相似的那条,分数够不够格:

// core.go handleQueryResults
mostSimilarData := results[0]
simThreshold := c.GetVectorProviderConfig().Threshold
simThresholdRelation := c.GetVectorProviderConfig().ThresholdRelation
if compare(simThresholdRelation, mostSimilarData.Score, simThreshold) {
log.Infof("[%s] key accepted: %s with score: %f", PLUGIN_NAME, mostSimilarData.Text, mostSimilarData.Score)
// 命中,直接返回缓存的答案
} else {
log.Infof("[%s] score not meet the threshold %f: %s with score %f", PLUGIN_NAME, simThreshold, mostSimilarData.Text, mostSimilarData.Score)
proxywasm.ResumeHttpRequest() // 未命中,放行到模型
}

注意这里有两个配置项:threshold(阈值)和 thresholdRelation(比较方向)。官方文档解释了为什么:

Similarity measurement methods include Cosine, DotProduct, Euclidean, etc. The first two have higher similarity with larger values, while the latter has higher similarity with smaller values. Use gt for Cosine and DotProduct, and lt for Euclidean.

余弦相似度是越大越像,欧氏距离是越小越像。 配错方向的后果不是"不生效",而是完全反过来 —— 系统会专门挑最不相关的缓存返回给你。

更要命的是默认值:threshold 默认 1000thresholdRelation 默认 lt。这是给欧氏距离准备的一组默认值(距离小于 1000 就算像)。如果你换成余弦相似度却忘了改这两项,余弦值永远在 [-1, 1] 区间,永远小于 1000 —— 所有查询都会命中第一条结果,不管它有多不相关。

LiteLLM 的做法可以对照着看,它把这个问题从配置层面消除了:

# litellm/caching/redis_semantic_cache.py
if similarity_threshold is None:
raise ValueError("similarity_threshold must be provided, passed None")

self.similarity_threshold = similarity_threshold

# Convert similarity threshold [0,1] to distance threshold [0,2]
# For cosine distance: 0 = most similar, 2 = least similar
self.distance_threshold = 1 - similarity_threshold
self.embedding_model = embedding_model # 默认 "text-embedding-ada-002"

三个决策值得学

  1. similarity_threshold 没有默认值,不传直接抛异常。 这是对的 —— 语义缓存的阈值和你的业务容错度强相关,任何默认值都是错的。
  2. 对外只暴露"相似度"[0,1] 这一个语义,内部自己转成距离。用户不需要知道底层用的是距离还是相似度,也就不可能配反。
  3. 注释里把转换关系写死在代码旁边,而不是文档里。

Higress 给了你更大的灵活性(七种向量库、任意度量方式),代价是把一个方向性错误的可能留给了使用者。LiteLLM 收窄了接口,换来了不可能配错。 这是 API 设计上一个非常典型的取舍,没有绝对的对错,但你要知道自己选的是哪一边。

四、相似不等于等价

阈值配对了,还有一类问题是阈值救不了的。

下面这几组,在任何 embedding 模型下相似度都会很高:

问题 A问题 B相似度能共用答案吗
"北京今天天气怎么样""北京明天天气怎么样"极高绝对不能
"这段代码有什么问题""这段代码有什么优点"绝对不能
"订单 20260819001 的状态""订单 20260819002 的状态"极高绝对不能
"帮我写一个快排""帮我实现快速排序"极高可以

embedding 衡量的是"讲的是不是同一个话题",不是"要不要同一个答案"。 否定词、时间词、具体的 ID —— 这些恰恰是决定答案的关键,却几乎不影响向量距离。

所以语义缓存的安全适用范围其实很窄:

适合:稳定的知识问答(产品文档、FAQ、政策解释)、固定的分类/抽取任务、多语言的同义提问 ❌ 不适合:带时间的、带具体实体 ID 的、带否定和对比的、要求每次不同的创作类、多轮上下文相关的

一个务实的落地方式:不要全局开启。按路由分开 —— FAQ 那条链路开语义缓存,其他链路只开精确匹配或者不开。这正好用得上 03 篇讲的标签路由。

五、两个工程细节

5.1 流式响应的缓存

Higress 官方明确写了支持:

supports caching of both streaming and non-streaming responses

难点在于:流式响应是一片片吐出来的,网关必须在转发的同时把所有片段攒起来,等流正常结束才能写入缓存。中途断开的响应绝对不能进缓存,否则以后所有命中这条缓存的用户都会拿到一个被截断的答案。

命中缓存后往外吐的时候,还要把完整答案重新切成流式格式 —— 否则客户端收到的格式和未命中时不一致。

5.2 缓存与多租户的冲突

这是 04 篇遗留下来的一个交叉问题:缓存 key 里要不要带租户标识?

做法命中率风险
全局共享缓存最高A 租户的答案可能返回给 B 租户
key 里带租户 ID大幅降低安全

Higress 提供了 cache.cacheKeyPrefix(默认 higress-ai-cache:),LiteLLM 的语义缓存里有 _get_cache_filters / _cache_key_filter_expression 这样的过滤机制。

判断标准很简单:如果缓存里可能存进任何一个租户的私有数据,key 就必须带租户维度。 只有当被缓存的内容确定是公共知识(比如产品文档问答)时,共享缓存才是安全的。

RAG Agent Platform 里我踩过的教训是:这个决定必须在设计缓存的第一天做,因为一旦上线跑了一段时间,缓存里已经混进了私有数据,你连"清空重来"之外的补救办法都没有。

六、四层缓存的协同

回到第一节那张图。网关的缓存决策会直接影响下面两层的效果:

网关缓存命中率高 → 到达模型的请求少 → 但剩下的请求前缀更分散 → 推理引擎的 prefix caching 命中率反而下降。

原因是:重复度最高的那批请求被网关拦下来了,穿透到后端的都是"各不相同"的长尾请求。

再叠加 03 篇结尾讲的那件事 —— 如果网关按最低延迟把请求随机打散到多个后端实例,同一个 system prompt 的请求落在不同实例上,每个实例都要重算一遍前缀。

所以自建推理集群时,这三件事必须一起设计:

自建推理集群时,这三件事必须一起设计① 网关缓存拦掉字符串完全重复的请求这部分根本不会走到后端② 缓存感知路由相同前缀的请求粘在同一实例这一步是前后两步的接头③ 引擎 prefix caching复用已经算好的 KV Cache省下重复的 prefill 算力少了中间这一步,前后两步会互相抵消:网关按最低延迟把请求随机打散,同一个 system prompt 落在不同实例上,每个实例都得把这段前缀重算一遍 —— ③ 的命中率被 ② 的缺失直接吃掉。
三件事分属三个组件、通常也分属三个团队,这正是它容易被漏掉的原因:单看每一个都配置正确,合起来却互相抵消。

中间那步是最容易漏掉的,也是三者里唯一属于网关职责的。只做 ① 和 ③ 而不做 ②,等于让后两层互相拆台。

如果你的后端是外部 API(OpenAI、Anthropic),②③ 都由 provider 自己管,你只需要配合他们的 prompt caching 规则把稳定内容放在请求前面 —— 这也是一种"缓存感知",只不过作用在请求体的排版上。

下一篇07 - Token 速率与 QoS:缓存命中时 TTFT 直接归零,但没命中的那些请求怎么保证速度?优质客户要的"稳定 token 速率"到底靠什么撑住。

← 回到 专题索引  ·  Agent Infra 板块总览